<?php

/**
 * MNet Library 
 * Copyright (C) 2006-2008 Catalyst IT Ltd (http://www.catalyst.net.nz)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @package    mnet
 * @subpackage core
 * @author     Catalyst IT Ltd
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
 * @copyright  (C) 2006-2008 Catalyst IT Ltd http://catalyst.net.nz
 * @copyright  (C) portions from Moodle, (C) Martin Dougiamas http://dougiamas.com
 *
 */

require_once MNET_DIR.'/mnet_extension.php';

/**
 * The 'singlesignon' extension handles the MNet standard method of accomplishing
 * single sign on between systems which share a common username, or systems that
 * are willing to create an account when we call in with a username.
 * 
 * This extension goes hand-in-hand with the userprofile extension for syncing
 * arbitrary profile data.
 * 
 * Singlesignon works by a client being linked to a 'landing' page with a some
 * parameters that we call back the originating peer with. If successful, we
 * discover the user's username and can sign them in locally. After successful
 * sign in, we need to redirect the client to where they actually want to go.
 * 
 * TODO Singlesignout how?
 *
 */
abstract class mnet_extension_singlesignon extends mnet_extension {
    /**
	 * Use this method to check a landing user's credentials to see if they should
	 * be signed in.
	 * 
	 * If in doubt, call as:
	 *   $sso->confirm_mnet_session($_GET['token'], $_GET['idp], $_GET['wantsurl'], $_GET['remoteurl'])
	 * 
	 * @param string $token the token supplied by the landing user for validation
	 * @param string $remote_wwwroot the wwwroot string provided by the landing user
	 * @param string $wantsurl the desired destination URL of the landing user
	 * @param bool $remoteurl true or 1 if the $wantsurl is back at the initiating peer
	 * @return array arbitrary user profile data on success, false on validation failure
	 * @throws mnet_exception on call error
     */
    public function confirm_mnet_session($token, $remote_wwwroot, $wantsurl, $remoteurl = false) {
        global $MNET_APPLICATION;

        // try to find the peer
        $originating_peer = $MNET_APPLICATION->get-peer_by_wwwroot($remote_wwwroot);
        if (!$originating_peer) {
            throw new mnet_exception('Unable to locate originating peer '.$remote_wwwroot);
        }

        $client = new mnet_client();
        if ($originating_peer->get_protocol() == 1) {
            $client->set_method('auth/mnet/auth.php/user_authorise');
        } else {
            $client->set_method('auth/mnet/user_authorise');
        }
        $client->add_param($token, 'string');
        $client->add_param(sha1($_SERVER['HTTP_USER_AGENT'])); // but why?

        try {
            $result = $client->send($peer);
        }
        catch (mnet_xmlrpc_fault $e) {
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'SSO confirm failure: '.$e);
            return false;
        }
        
        // TODO finish
    }

    /**
     * Start an RPC jump session and returns the URL to redirect the user to.
     * 
     * Will call back to various methods to store the created session data
     *
     * @param string $username the username that wishes to jump
     * @param mnet_peer $destination_peer the peer record for the destination host
     * @param string $wantsurl the URL, either on the local or remote system, to jump to on success
     * @param bool $wantslocal if true, the $wantsurl is relative to the jumping server's wwwroot, not the destination's
     * @return string the absolute URL to redirect the user to
     */
    public function start_jump_session($username, mnet_peer $destination_peer, $wantsurl, $wantslocal = false) {
        global $MNET, $MNET_APPLICATION;

        $params = array(
            'token'      => $this->generate_token(),
            'idp'        => $MNET->get_local_wwwroot(),
            'wantsurl'   => $wantsurl,
            'remoteurl'  => $wantslocal
        );

        // Register this session with the application
        $this->save_session($username, $params['token'], $destination_peer);

        // Construct the URL
        $url = $destination_peer->get_wwwroot().$this->get_remote_land_path($destination_peer);
        $url .= '?'.http_build_query($params, '', '&');

        return $url;
    }
    
    /**
	 * Generates a random secret token
	 * 
	 * @return string the random token
     */
    protected function generate_token() {
        return sha1(str_shuffle(''.mt_rand().time()));
    }
    
    /**
	 * Implemented by the application, this method starts a new MNet authentication
	 * session, storing any details about the local user it deems necessary to verify
	 * a token when the remote peer calls back.
	 *
	 * @param string $username the username to start the session for
	 * @param string $token a random token that the remote peer will call back with to identify this session
	 * @param mnet_peer $destination_peer the remote peer's record
     */
    protected abstract function save_session($username, $token, mnet_peer $destination_peer);
    
    /**
	 * The partner to save_session, this method should be implemented by the application
	 * to check if a session is registered against the token and wwwroot specified. If so,
	 * the username should be returned, otherwise false.
	 *
	 * @param string $token a token, as saved to the session with save_session()
	 * @param string $remote_wwwroot the wwwroot that this session was created towards
	 * @return mixed if the session is valid, the username it is for, otherwise return false
     */
    protected abstract function verify_session($token, $remote_wwwroot);
    
    /**
     * This method must be implemented by the application, and return a set
     * of data about the user to allow them to create the new user.
     * 
     * TODO verify below
     * Standard fields are: (provide as many as you can)
     *  - email
     *  - confirmed (email address confirmed)
     *  - deleted (bool)
     *  - firstname
     *  - lastname
     *  - city
     *  - country (ISO or full???)
     *  - lang (eg. en-nz) ???
     *  - timezone (eg. Pacific/Auckland) ???
     *  - description
     *  - mailformat (html/plain) ??? 
     *  - maildigest (???)
     *  - maildisplay (???)
     *  - htmleditor (bool???)
     *  - picture (bool???)
     *  
     * And any other custom fields you like (although be careful with MNet-1 peers
     * 
     * @param string $username the user's username
     * @return array associative array with above fields
     */
    protected abstract function get_user_profile($username);
    
    /**
	 * This must return the path, relative to the wwwroot, that the remote peer expects
	 * authentication land requests on, eg. '/auth/mnet/land.php' for Moodle
	 * 
	 * @param mnet_peer the peer to get the land path for
	 * @return string URL (relative to wwwroot) to the landing script
     */
    protected abstract function get_remote_land_path(mnet_peer $destination_peer);

    /**
	 * The remote side for confirm_mnet_session(). Checks for a saved session against
	 * the specified token and return arbitrary user profile details if successful, or
	 * an RPC fault on failure
	 * 
	 * @param string $token incoming token parameter
	 * @param string $ua a sha1 of the user agent they arrived with
	 * @return array a set of user profile information
	 * @throws mnet_xmlrpc_fault on failure
     */
    public function rpc_user_authorise($token, $ua) {
        global $MNET_APPLICATION;

        $remote_peer = mnet_server::get_current_client();
        if (!$remote_peer) {
            throw new mnet_exception('no remote peer???');
        }

        // So, do they have a session with us?
        $username = $this->verify_session($token, $remote_peer->get_wwwroot());
        if ($username == false) {
            // no session known
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Failed to verify SSO session, token: '.$token.', peer: '.$remote_peer->get_wwwroot());
            throw new mnet_xmlrpc_fault(1, 'No session exists, or it has timed out');
        }

        // Right, so the session was ok. Now collect some information about the local
        // user to provide to the landing party
        $user_details = $this->get_user_profile($username);
        $user_details['username'] = $username;
        $user_details['picture'] = 0; // for now
        $user_details['myhosts'] = array(); // ???
        $user_details['confirmed'] = 1;

        return $user_details;
    }
    
    /**
	 * Endpoint for kill_children() to kill the session on this
	 * parent IdP
	 *
	 * @param string $username the username for the session to kill
	 * @param string $ua the sha1 of their user agent
	 * @return string a plaintext report of what happened
     */
    public function rpc_kill_parent($username, $ua) {
      
    }
    
    /**
	 * Incoming request to remove a session for the provided user
	 * 
	 * We should also try to terminate the session on any other hosts that
	 * we are aware the user is SSO'd to
	 * 
	 * @param string $username the username to sign out
	 * @param string $ua sha1 of their user agent
	 * @return string what we did
     */
    public function rpc_kill_children($username, $ua) {
        // TODO find child sessions
        
        $this->kill_local_user_session($username, $ua, false);
        
        // TODO return what actually happened
        return 'Killed sessions';
    }

    /**
     * Call this when a user local to this peer logs out and you would like
     * to remove their sessions on other peers
     *
	 * @param string $username the username for the session to kill
	 * @param string $ua the sha1 of their user agent
     */
    public function kill_children($username, $ua) {
        global $MNET_APPLICATION;

        // Find the peers with which we have sessions
        $peers = $this->get_remote_sessions($username, $ua);

        // Kill those sessions
        foreach ($peers as $peer) {
            $MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Killing child session on '.$peer->get_wwwroot());

            $client = new mnet_client();
            if ($peer->get_protocol() == 1) {
                $client->set_method('auth/mnet/auth.php/kill_child');
            }
            else {
                $client->set_method('mnet/singlesignon/kill_child');
            }

            $client->add_param($username, 'string');
            $client->add_param($ua, 'string');

            try {
                $client->send($peer);
            }
            catch (Exception $e) {
                $MNET_APPLICATION->log(MNET_LOG_ERR, 'Failed to kill child session on '.$peer->get_wwwroot());
            }
        }

        // Now delete these sessions locally
        $this->kill_local_user_session($username, $ua, true);

        return true;
    }
    
    /**
     * Called from kill_children() on the other side to request that we destroy local
     * sessions for the user
     * 
     * TODO how is this different to kill_parent() ?
	 * @param string $username the username for the session to kill
	 * @param string $ua the sha1 of their user agent
     * @return bool true if we found and destroyed a session
     */
    public function rpc_kill_child($username, $ua) {
        // Look up who's calling
        $peer = mnet_server::get_current_client();
        if ($peer) {
            // find a session for this peer
            $peers = $this->get_remote_sessions($username, $ua);
            foreach ($peers as $sess_peer) {
                if ($peer->get_wwwroot() == $sess_peer->get_wwwroot()) {
                    // this is the session to kill
                    // TODO kill_local_user_session() isn't really appropriate,
                    // since it kills all sessions...
                    $this->kill_local_user_session($username, $ua, false);

                    return true;
                }
            }
        }

        return false;
    }

    /**
	 * This must be implemented, and causes any sessions currently open for $username
	 * to be terminated. $ua is provided for validation against the original session.
	 *
	 * @param string $username the username to sign out
	 * @param string $ua sha1 of their user agent
     * @param bool $delete_only if true, the MNet session should simply be removed. If false, the corresponding PHP session should be destroyed as well 
     */
    protected abstract function kill_local_user_session($username, $ua, $delete_only = true);

    /**
 	 * Applications must implement this. It must return an array of mnet_peer
 	 * peers with which a session for this user might be open.
 	 * 
 	 * @param string $username the username for the session to kill
	 * @param string $ua the sha1 of their user agent
 	 * @return array an array of mnet_peer records (or derivatives)
     */
    protected abstract function get_remote_sessions($username, $ua);

    /**
	 * Notify an identity provider (IdP) that the following users are still
	 * online here. This should be called periodically for each alive local
	 * session that was logged in by MNet SSO
	 * 
	 * @param mnet_peer $peer the peer to send the keepalive to
	 * @param array $usernames an array of usernames to touch the sessions of
     */
    public function keepalive_client(mnet_peer $peer, $usernames) {
      global $MNET_APPLICATION;

      $client = new mnet_client();
      
      if ($peer->get_protocol() == 1) {
        $client->set_method('auth/mnet/auth.php/keepalive_server');
      }
      else {
        $client->set_method('mnet/singlesignon/keepalive_server');
      }

      $client->add_param($usernames);

      try {
        $client->send($peer);
      }
      catch (Exception $e) {
        $MNET_APPLICATION->log(MNET_LOG_ERR, 'Failed to refresh sessions on '.$peer.': '.$e);
      }

      // TODO other stuff probably - see Moodle implementation
    }

    /**
	 * The other side of keepalive_client(), accepts an array of usernames
	 * to refresh sessions for locally
	 * 
	 * @param array $usernames an array of usernames for users that should remain logged in here
	 * @return string 'ok' or an error message
     */
    public function rpc_keepalive_server($usernames) {
      $this->refresh_sessions($usernames);
    }

    /**
	 * The implementation should locate the sessions for each of these usernames
	 * and update their expiry so they remain logged in
	 * 
	 * @param array $usernames an array of usernames for users that should remain logged in here
	 * @return string 'ok' or an error message
     */
    protected abstract function refresh_sessions($usernames);
}
